我們來繼續看 SOLID 的第四條: Interface Segregation Principle(介面隔離原則,簡稱 ISP)
先來認識一下 ISP 的身世
ISP 一樣是 Uncle Bob 提出的,這條原則不是憑空想出來的,而是他早期幫全錄(Xerox)做顧問時,踩過的坑
當時全錄要做一台「多功能事務機」,身兼列印、裝訂、傳真多種功能,工程師很直覺地把所有功能都寫進同一個 Job 類別裡
後來 Uncle Bob 發現,只要新增或修改一個跟「傳真」有關的方法,連只用到「列印」功能的那群 Client 端程式碼都要跟著重新編譯,即使它們根本沒呼叫過傳真的方法
此經驗後來被寫成原則,原文是這樣說的:
Clients should not be forced to depend on methods it does not use.
「客戶端不應該被迫依賴用不到的方法」
也可以說:
不要把不相關的功能塞進同一個介面,逼著實作它的人,硬生出一堆自己用不到的方法
拿剛剛的例子來寫,介面大概長這樣
public interface IMultiFunctionDevice // 多功能印表機介面
{
void Print(Document doc);
void Scan(Document doc);
void Fax(Document doc);
}
如果只是一台最陽春的印表機,沒有掃描、傳真功能呢
public class SimplePrinter : IMultiFunctionDevice // 簡單印表機 實作 多功能印表機介面
{
public void Print(Document doc) => Console.WriteLine("列印中...");
// 沒有這兩個功能,但介面規定要實作,只好丟例外交差
public void Scan(Document doc) => throw new NotSupportedException("這台印表機不能掃描");
public void Fax(Document doc) => throw new NotSupportedException("這台印表機不能傳真");
}
SimplePrinter 它被迫實作了兩個它做不到的方法
呼叫端拿到一個 IMultiFunctionDevice,沒辦法放心呼叫 Scan()
因為底下只要換成 SimplePrinter 就會噴錯
言下之意,關於 IMultiFunctionDevice 的介面
不應該強迫用戶依賴它們未使用的方法
那該怎麼辦呢,最直白就是拆,拆開來就簡單多了,我們依照「誰在用」拆成三個小介面
public interface IPrinter { void Print(Document doc); }
public interface IScanner { void Scan(Document doc); }
public interface IFax { void Fax(Document doc); }
public class SimplePrinter : IPrinter
{
public void Print(Document doc) => Console.WriteLine("列印中...");
}
public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
public void Print(Document doc) => Console.WriteLine("列印中...");
public void Scan(Document doc) => Console.WriteLine("掃描中...");
public void Fax(Document doc) => Console.WriteLine("傳真中...");
}
SimplePrinter 只實作自己做得到的,只想印文件的 client,也只需要依賴 IPrinter,不用被迫認識 Scan、Fax 這些用不到的方法
介面隔離原則,我理解成: 每一種 client 的依賴,各自關在自己的介面裡,彼此不互相波及並達成隔離的目的
client 有可能是誰? 可能任何依賴或者呼叫這個介面的使用者
好,帶著這個感覺,我們再回到柴咖啡
上一篇阿柴把促銷邏輯整理成乾淨的 IDiscount,CheckoutService 終於不用再寫一堆 is 判斷,本來想說這下總算穩了
但小黑又跑來了:「阿柴,最近很多人問有沒有集點,順便把點數折抵也做進來,跟打折用同一套邏輯處理就好」
阿柴心想:「好,都是『優惠』的概念,乾脆都塞進 IDiscount 好了」
public interface IDiscount // 原先 IDiscount 的介面
{
decimal Apply(Order order, decimal currentTotal);
}
於是把介面擴大,並改名叫 IPromotion:
public interface IPromotion
{
decimal Apply(Order order, decimal currentTotal); // 結帳時計算套用後金額
decimal RedeemPoints(int points); // 點數折抵
}
接下來把三種折扣,跟一個「點數折抵」,通通實作這個介面
public class PercentageDiscount : IPromotion
{
private readonly decimal _rate;
public PercentageDiscount(decimal rate) => _rate = rate;
public decimal Apply(Order order, decimal currentTotal) => currentTotal * _rate;
// 打折活動跟集點無關,但介面規定要實作,只好回傳一個 0 交差
public decimal RedeemPoints(int points) => 0;
}
public class BuyOneGetOneDiscount : IPromotion
{
public decimal Apply(Order order, decimal currentTotal) { /*略*/ return currentTotal; }
public decimal RedeemPoints(int points) => 0; // 用不到集點
}
public class PointsRedemption : IPromotion
{
// 但點數折抵本質上不是「打折」,是拿點數換錢,但因為要塞進同一個介面…
public decimal Apply(Order order, decimal currentTotal)
=> throw new NotSupportedException("點數折抵請改用 RedeemPoints,這裡用不到");
public decimal RedeemPoints(int points) => points * 0.5m; // 每點折 0.5 元
}
寫完當下沒事,介面統一,看起來一切美好,好交差
現場當機,客人在櫃檯前面等,阿柴滿頭問號

雖然 PercentageDiscount、BuyOneGetOneDiscount、PointsRedemption 都是同一個 IPromotion 型別,只要宣告成 IPromotion,程式完全看不出裡面裝的是哪一種,編譯也不會錯誤
結果阿柴在結帳的地方,手滑把「今天要套用的促銷」設成了 PointsRedemption,接著執行到 Apply()直接噴錯
IPromotion todayPromotion = new PointsRedemption(); // 型別上是 IPromotion,編譯沒問題
var total = todayPromotion.Apply(order, 100);
System.NotSupportedException: 點數折抵請改用 RedeemPoints,這裡用不到
客戶端不應該被迫依賴它們用不到的方法
換句話說:一個介面,應該只服務「同一群需要它全部方法」的 client,而不是把多種 client 的需求,一同打包塞進同一份契約
PointsRedemption 表面上跟 PercentageDiscount、BuyOneGetOneDiscount 一樣
都實作了 IPromotion,長得一模一樣
但聰明的你,細看也會發現,它們其實服務著完全不同的使用情境:
Apply
RedeemPoints
兩種不同的 client,各自只需要一小塊功能,卻被 IPromotion 這一份契約綁在一起
而 PointsRedemption 是實作 IPromotion,結帳流程完全沒有辦法在編譯期擋下它
只能等到執行期真的被拿去結帳,才會爆炸
拆拆看,把 IPromotion 依照「誰在用」拆成兩個小介面
public interface IDiscount // 結帳流程
{
decimal Apply(Order order, decimal currentTotal);
}
public interface IPointRedeemable // 兌換點數頁面
{
decimal RedeemPoints(int points);
}
public class PercentageDiscount : IDiscount
{
private readonly decimal _rate;
public PercentageDiscount(decimal rate) => _rate = rate;
public decimal Apply(Order order, decimal currentTotal) => currentTotal * _rate;
}
public class BuyOneGetOneDiscount : IDiscount
{
public decimal Apply(Order order, decimal currentTotal) { /* 略 */ return currentTotal; }
}
public class PointsRedemption : IPointRedeemable
{
public decimal RedeemPoints(int points) => points * 0.5m;
}
那該怎麼使用呢
// 結帳方法只依賴它需要的 IDiscount
public decimal Checkout(Order order, IDiscount discount)
{
decimal total = _calculator.Calculate(order);
return discount.Apply(order, total);
}
// 嘗試把點數折抵丟進去結帳:
Checkout(order, new PointsRedemption()); // ❌ 編譯器直接報錯
阿柴又平安度過一天了,平安快樂,可口可樂
讓我們來復盤,透過 ISP 介面隔離,我們可以學到什麼:
下次處理一個介面,可以反問自己:
這時候我的第一個問題,那我就把每個方法都拆成獨立介面就好了呀,一個方法一個介面,總不會錯了吧?
NO NO NO ,當專案一龐大的時候,無限增生的介面,會讓可讀性跟可維護性大爆炸
那讓我們依照「誰在用」來分,不是依照「方法數量」來分
同一群 client 會一起用到的方法,放在同一個介面沒問題
像 IPointRedeemable,之後就算再多幾種「點數怎麼折抵」的玩法(換飲料、換折價券…),只要都是「點數兌換頁面」這一個 client 在用
通通實作同一個 IPointRedeemable 就好,沒必要因為「類別數量變多」就跟著拆更多介面
該拆的判斷標準是「這群 client 要的方法組合是否相同」,不是「有幾個類別在實作」
ISP 該保護的是「每個 client 只依賴它需要的方法」,不是「把相關功能塞進同一個介面比較方便」
下一篇,我們繼續看 SOLID 最後一條:D——依賴反轉原則
使人瘋狂的 SOLID 原則: 介面隔離原則 (Interface Segregation Principle)
介面隔離原則 ISP 深度指南 SOLID 實踐應用介面拆分技巧降低耦合與提高內聚優化擴展性